/**
 * @license
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import '../../../scripts/bundled-polymer.js';

import '../../../behaviors/async-foreach-behavior/async-foreach-behavior.js';
import '../../../behaviors/dom-util-behavior/dom-util-behavior.js';
import '../../../behaviors/fire-behavior/fire-behavior.js';
import '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';
import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js';
import '../../../styles/shared-styles.js';
import '../../core/gr-navigation/gr-navigation.js';
import '../../core/gr-reporting/gr-reporting.js';
import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
import '../../diff/gr-diff-host/gr-diff-host.js';
import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
import '../../edit/gr-edit-file-controls/gr-edit-file-controls.js';
import '../../shared/gr-button/gr-button.js';
import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
import '../../shared/gr-icons/gr-icons.js';
import '../../shared/gr-linked-text/gr-linked-text.js';
import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
import '../../shared/gr-select/gr-select.js';
import '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
import '../gr-file-list-constants.js';
import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-file-list_html.js';

// Maximum length for patch set descriptions.
const PATCH_DESC_MAX_LENGTH = 500;
const WARN_SHOW_ALL_THRESHOLD = 1000;
const LOADING_DEBOUNCE_INTERVAL = 100;

const SIZE_BAR_MAX_WIDTH = 61;
const SIZE_BAR_GAP_WIDTH = 1;
const SIZE_BAR_MIN_WIDTH = 1.5;

const RENDER_TIMING_LABEL = 'FileListRenderTime';
const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';

const FileStatus = {
  A: 'Added',
  C: 'Copied',
  D: 'Deleted',
  M: 'Modified',
  R: 'Renamed',
  W: 'Rewritten',
  U: 'Unchanged',
};

/**
 * @appliesMixin Gerrit.AsyncForeachMixin
 * @appliesMixin Gerrit.DomUtilMixin
 * @appliesMixin Gerrit.FireMixin
 * @appliesMixin Gerrit.KeyboardShortcutMixin
 * @appliesMixin Gerrit.PatchSetMixin
 * @appliesMixin Gerrit.PathListMixin
 * @extends Polymer.Element
 */
class GrFileList extends mixinBehaviors( [
  Gerrit.AsyncForeachBehavior,
  Gerrit.DomUtilBehavior,
  Gerrit.FireBehavior,
  Gerrit.KeyboardShortcutBehavior,
  Gerrit.PatchSetBehavior,
  Gerrit.PathListBehavior,
], GestureEventListeners(
    LegacyElementMixin(
        PolymerElement))) {
  static get template() { return htmlTemplate; }

  static get is() { return 'gr-file-list'; }
  /**
   * Fired when a draft refresh should get triggered
   *
   * @event reload-drafts
   */

  static get properties() {
    return {
    /** @type {?} */
      patchRange: Object,
      patchNum: String,
      changeNum: String,
      /** @type {?} */
      changeComments: Object,
      drafts: Object,
      revisions: Array,
      projectConfig: Object,
      selectedIndex: {
        type: Number,
        notify: true,
      },
      keyEventTarget: {
        type: Object,
        value() { return document.body; },
      },
      /** @type {?} */
      change: Object,
      diffViewMode: {
        type: String,
        notify: true,
        observer: '_updateDiffPreferences',
      },
      editMode: {
        type: Boolean,
        observer: '_editModeChanged',
      },
      filesExpanded: {
        type: String,
        value: GrFileListConstants.FilesExpandedState.NONE,
        notify: true,
      },
      _filesByPath: Object,
      _files: {
        type: Array,
        observer: '_filesChanged',
        value() { return []; },
      },
      _loggedIn: {
        type: Boolean,
        value: false,
      },
      _reviewed: {
        type: Array,
        value() { return []; },
      },
      diffPrefs: {
        type: Object,
        notify: true,
        observer: '_updateDiffPreferences',
      },
      /** @type {?} */
      _userPrefs: Object,
      _showInlineDiffs: Boolean,
      numFilesShown: {
        type: Number,
        notify: true,
      },
      /** @type {?} */
      _patchChange: {
        type: Object,
        computed: '_calculatePatchChange(_files)',
      },
      fileListIncrement: Number,
      _hideChangeTotals: {
        type: Boolean,
        computed: '_shouldHideChangeTotals(_patchChange)',
      },
      _hideBinaryChangeTotals: {
        type: Boolean,
        computed: '_shouldHideBinaryChangeTotals(_patchChange)',
      },

      _shownFiles: {
        type: Array,
        computed: '_computeFilesShown(numFilesShown, _files)',
      },

      /**
       * The amount of files added to the shown files list the last time it was
       * updated. This is used for reporting the average render time.
       */
      _reportinShownFilesIncrement: Number,

      _expandedFilePaths: {
        type: Array,
        value() { return []; },
      },
      _displayLine: Boolean,
      _loading: {
        type: Boolean,
        observer: '_loadingChanged',
      },
      /** @type {Gerrit.LayoutStats|undefined} */
      _sizeBarLayout: {
        type: Object,
        computed: '_computeSizeBarLayout(_shownFiles.*)',
      },

      _showSizeBars: {
        type: Boolean,
        value: true,
        computed: '_computeShowSizeBars(_userPrefs)',
      },

      /** @type {Function} */
      _cancelForEachDiff: Function,

      _showDynamicColumns: {
        type: Boolean,
        computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
                '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
      },
      /** @type {Array<string>} */
      _dynamicHeaderEndpoints: {
        type: Array,
      },
      /** @type {Array<string>} */
      _dynamicContentEndpoints: {
        type: Array,
      },
      /** @type {Array<string>} */
      _dynamicSummaryEndpoints: {
        type: Array,
      },
    };
  }

  static get observers() {
    return [
      '_expandedPathsChanged(_expandedFilePaths.splices)',
      '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
        '_loading)',
    ];
  }

  get keyBindings() {
    return {
      esc: '_handleEscKey',
    };
  }

  keyboardShortcuts() {
    return {
      [this.Shortcut.LEFT_PANE]: '_handleLeftPane',
      [this.Shortcut.RIGHT_PANE]: '_handleRightPane',
      [this.Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
      [this.Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
      [this.Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
      [this.Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
      [this.Shortcut.NEXT_LINE]: '_handleCursorNext',
      [this.Shortcut.PREV_LINE]: '_handleCursorPrev',
      [this.Shortcut.NEW_COMMENT]: '_handleNewComment',
      [this.Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
      [this.Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
      [this.Shortcut.OPEN_FILE]: '_handleOpenFile',
      [this.Shortcut.NEXT_CHUNK]: '_handleNextChunk',
      [this.Shortcut.PREV_CHUNK]: '_handlePrevChunk',
      [this.Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
      [this.Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',

      // Final two are actually handled by gr-comment-thread.
      [this.Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
      [this.Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
    };
  }

  /** @override */
  created() {
    super.created();
    this.addEventListener('keydown',
        e => this._scopedKeydownHandler(e));
  }

  /** @override */
  attached() {
    super.attached();
    Gerrit.awaitPluginsLoaded().then(() => {
      this._dynamicHeaderEndpoints = Gerrit._endpoints.getDynamicEndpoints(
          'change-view-file-list-header');
      this._dynamicContentEndpoints = Gerrit._endpoints.getDynamicEndpoints(
          'change-view-file-list-content');
      this._dynamicSummaryEndpoints = Gerrit._endpoints.getDynamicEndpoints(
          'change-view-file-list-summary');

      if (this._dynamicHeaderEndpoints.length !==
          this._dynamicContentEndpoints.length) {
        console.warn(
            'Different number of dynamic file-list header and content.');
      }
      if (this._dynamicHeaderEndpoints.length !==
          this._dynamicSummaryEndpoints.length) {
        console.warn(
            'Different number of dynamic file-list headers and summary.');
      }
    });
  }

  /** @override */
  detached() {
    super.detached();
    this._cancelDiffs();
  }

  /**
   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
   * events must be scoped to a component level (e.g. `enter`) in order to not
   * override native browser functionality.
   *
   * Context: Issue 7277
   */
  _scopedKeydownHandler(e) {
    if (e.keyCode === 13) {
      // Enter.
      this._handleOpenFile(e);
    }
  }

  reload() {
    if (!this.changeNum || !this.patchRange.patchNum) {
      return Promise.resolve();
    }

    this._loading = true;

    this.collapseAllDiffs();
    const promises = [];

    promises.push(this._getFiles().then(filesByPath => {
      this._filesByPath = filesByPath;
    }));
    promises.push(this._getLoggedIn()
        .then(loggedIn => this._loggedIn = loggedIn)
        .then(loggedIn => {
          if (!loggedIn) { return; }

          return this._getReviewedFiles().then(reviewed => {
            this._reviewed = reviewed;
          });
        }));

    promises.push(this._getDiffPreferences().then(prefs => {
      this.diffPrefs = prefs;
    }));

    promises.push(this._getPreferences().then(prefs => {
      this._userPrefs = prefs;
    }));

    return Promise.all(promises).then(() => {
      this._loading = false;
      this._detectChromiteButler();
      this.$.reporting.fileListDisplayed();
    });
  }

  _detectChromiteButler() {
    const hasButler = !!document.getElementById('butler-suggested-owners');
    if (hasButler) {
      this.$.reporting.reportExtension('butler');
    }
  }

  get diffs() {
    const diffs = dom(this.root).querySelectorAll('gr-diff-host');
    // It is possible that a bogus diff element is hanging around invisibly
    // from earlier with a different patch set choice and associated with a
    // different entry in the files array. So filter on visible items only.
    return Array.from(diffs).filter(
        el => !!el && !!el.style && el.style.display !== 'none');
  }

  openDiffPrefs() {
    this.$.diffPreferencesDialog.open();
  }

  _calculatePatchChange(files) {
    const magicFilesExcluded = files.filter(files =>
      !this.isMagicPath(files.__path)
    );

    return magicFilesExcluded.reduce((acc, obj) => {
      const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
      const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
      const total_size = (obj.size && obj.binary) ? obj.size : 0;
      const size_delta_inserted =
          obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
      const size_delta_deleted =
          obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;

      return {
        inserted: acc.inserted + inserted,
        deleted: acc.deleted + deleted,
        size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
        size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
        total_size: acc.total_size + total_size,
      };
    }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
      size_delta_deleted: 0, total_size: 0});
  }

  _getDiffPreferences() {
    return this.$.restAPI.getDiffPreferences();
  }

  _getPreferences() {
    return this.$.restAPI.getPreferences();
  }

  _togglePathExpanded(path) {
    // Is the path in the list of expanded diffs? IF so remove it, otherwise
    // add it to the list.
    const pathIndex = this._expandedFilePaths.indexOf(path);
    if (pathIndex === -1) {
      this.push('_expandedFilePaths', path);
    } else {
      this.splice('_expandedFilePaths', pathIndex, 1);
    }
  }

  _togglePathExpandedByIndex(index) {
    this._togglePathExpanded(this._files[index].__path);
  }

  _updateDiffPreferences() {
    if (!this.diffs.length) { return; }
    // Re-render all expanded diffs sequentially.
    this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);
    this._renderInOrder(this._expandedFilePaths, this.diffs,
        this._expandedFilePaths.length);
  }

  _forEachDiff(fn) {
    const diffs = this.diffs;
    for (let i = 0; i < diffs.length; i++) {
      fn(diffs[i]);
    }
  }

  expandAllDiffs() {
    this._showInlineDiffs = true;

    // Find the list of paths that are in the file list, but not in the
    // expanded list.
    const newPaths = [];
    let path;
    for (let i = 0; i < this._shownFiles.length; i++) {
      path = this._shownFiles[i].__path;
      if (!this._expandedFilePaths.includes(path)) {
        newPaths.push(path);
      }
    }

    this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths));
  }

  collapseAllDiffs() {
    this._showInlineDiffs = false;
    this._expandedFilePaths = [];
    this.filesExpanded = this._computeExpandedFiles(
        this._expandedFilePaths.length, this._files.length);
    this.$.diffCursor.handleDiffUpdate();
  }

  /**
   * Computes a string with the number of comments and unresolved comments.
   *
   * @param {!Object} changeComments
   * @param {!Object} patchRange
   * @param {string} path
   * @return {string}
   */
  _computeCommentsString(changeComments, patchRange, path) {
    const unresolvedCount =
        changeComments.computeUnresolvedNum({
          patchNum: patchRange.basePatchNum,
          path,
        }) +
        changeComments.computeUnresolvedNum({
          patchNum: patchRange.patchNum,
          path,
        });
    const commentCount =
        changeComments.computeCommentCount({
          patchNum: patchRange.basePatchNum,
          path,
        }) +
        changeComments.computeCommentCount({
          patchNum: patchRange.patchNum,
          path,
        });
    const commentString = GrCountStringFormatter.computePluralString(
        commentCount, 'comment');
    const unresolvedString = GrCountStringFormatter.computeString(
        unresolvedCount, 'unresolved');

    return commentString +
        // Add a space if both comments and unresolved
        (commentString && unresolvedString ? ' ' : '') +
        // Add parentheses around unresolved if it exists.
        (unresolvedString ? `(${unresolvedString})` : '');
  }

  /**
   * Computes a string with the number of drafts.
   *
   * @param {!Object} changeComments
   * @param {!Object} patchRange
   * @param {string} path
   * @return {string}
   */
  _computeDraftsString(changeComments, patchRange, path) {
    const draftCount =
        changeComments.computeDraftCount({
          patchNum: patchRange.basePatchNum,
          path,
        }) +
        changeComments.computeDraftCount({
          patchNum: patchRange.patchNum,
          path,
        });
    return GrCountStringFormatter.computePluralString(draftCount, 'draft');
  }

  /**
   * Computes a shortened string with the number of drafts.
   *
   * @param {!Object} changeComments
   * @param {!Object} patchRange
   * @param {string} path
   * @return {string}
   */
  _computeDraftsStringMobile(changeComments, patchRange, path) {
    const draftCount =
        changeComments.computeDraftCount({
          patchNum: patchRange.basePatchNum,
          path,
        }) +
        changeComments.computeDraftCount({
          patchNum: patchRange.patchNum,
          path,
        });
    return GrCountStringFormatter.computeShortString(draftCount, 'd');
  }

  /**
   * Computes a shortened string with the number of comments.
   *
   * @param {!Object} changeComments
   * @param {!Object} patchRange
   * @param {string} path
   * @return {string}
   */
  _computeCommentsStringMobile(changeComments, patchRange, path) {
    const commentCount =
        changeComments.computeCommentCount({
          patchNum: patchRange.basePatchNum,
          path,
        }) +
        changeComments.computeCommentCount({
          patchNum: patchRange.patchNum,
          path,
        });
    return GrCountStringFormatter.computeShortString(commentCount, 'c');
  }

  /**
   * @param {string} path
   * @param {boolean=} opt_reviewed
   */
  _reviewFile(path, opt_reviewed) {
    if (this.editMode) { return; }
    const index = this._files.findIndex(file => file.__path === path);
    const reviewed = opt_reviewed || !this._files[index].isReviewed;

    this.set(['_files', index, 'isReviewed'], reviewed);
    if (index < this._shownFiles.length) {
      this.notifyPath(`_shownFiles.${index}.isReviewed`);
    }

    this._saveReviewedState(path, reviewed);
  }

  _saveReviewedState(path, reviewed) {
    return this.$.restAPI.saveFileReviewed(this.changeNum,
        this.patchRange.patchNum, path, reviewed);
  }

  _getLoggedIn() {
    return this.$.restAPI.getLoggedIn();
  }

  _getReviewedFiles() {
    if (this.editMode) { return Promise.resolve([]); }
    return this.$.restAPI.getReviewedFiles(this.changeNum,
        this.patchRange.patchNum);
  }

  _getFiles() {
    return this.$.restAPI.getChangeOrEditFiles(
        this.changeNum, this.patchRange);
  }

  /**
   * The closure compiler doesn't realize this.specialFilePathCompare is
   * valid.
   *
   * @suppress {checkTypes}
   */
  _normalizeChangeFilesResponse(response) {
    if (!response) { return []; }
    const paths = Object.keys(response).sort(this.specialFilePathCompare);
    const files = [];
    for (let i = 0; i < paths.length; i++) {
      const info = response[paths[i]];
      info.__path = paths[i];
      info.lines_inserted = info.lines_inserted || 0;
      info.lines_deleted = info.lines_deleted || 0;
      files.push(info);
    }
    return files;
  }

  /**
   * Handle all events from the file list dom-repeat so event handleers don't
   * have to get registered for potentially very long lists.
   */
  _handleFileListClick(e) {
    // Traverse upwards to find the row element if the target is not the row.
    let row = e.target;
    while (!row.classList.contains('row') && row.parentElement) {
      row = row.parentElement;
    }

    const path = row.dataset.path;
    // Handle checkbox mark as reviewed.
    if (e.target.classList.contains('markReviewed')) {
      e.preventDefault();
      return this._reviewFile(path);
    }

    // If a path cannot be interpreted from the click target (meaning it's not
    // somewhere in the row, e.g. diff content) or if the user clicked the
    // link, defer to the native behavior.
    if (!path || this.descendedFromClass(e.target, 'pathLink')) { return; }

    // Disregard the event if the click target is in the edit controls.
    if (this.descendedFromClass(e.target, 'editFileControls')) { return; }

    e.preventDefault();
    this._togglePathExpanded(path);
  }

  _handleLeftPane(e) {
    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
      return;
    }

    e.preventDefault();
    this.$.diffCursor.moveLeft();
  }

  _handleRightPane(e) {
    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
      return;
    }

    e.preventDefault();
    this.$.diffCursor.moveRight();
  }

  _handleToggleInlineDiff(e) {
    if (this.shouldSuppressKeyboardShortcut(e) ||
        this.modifierPressed(e) ||
        this.$.fileCursor.index === -1) { return; }

    e.preventDefault();
    this._togglePathExpandedByIndex(this.$.fileCursor.index);
  }

  _handleToggleAllInlineDiffs(e) {
    if (this.shouldSuppressKeyboardShortcut(e)) { return; }

    e.preventDefault();
    this._toggleInlineDiffs();
  }

  _handleCursorNext(e) {
    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
      return;
    }

    if (this._showInlineDiffs) {
      e.preventDefault();
      this.$.diffCursor.moveDown();
      this._displayLine = true;
    } else {
      // Down key
      if (this.getKeyboardEvent(e).keyCode === 40) { return; }
      e.preventDefault();
      this.$.fileCursor.next();
      this.selectedIndex = this.$.fileCursor.index;
    }
  }

  _handleCursorPrev(e) {
    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
      return;
    }

    if (this._showInlineDiffs) {
      e.preventDefault();
      this.$.diffCursor.moveUp();
      this._displayLine = true;
    } else {
      // Up key
      if (this.getKeyboardEvent(e).keyCode === 38) { return; }
      e.preventDefault();
      this.$.fileCursor.previous();
      this.selectedIndex = this.$.fileCursor.index;
    }
  }

  _handleNewComment(e) {
    if (this.shouldSuppressKeyboardShortcut(e) ||
        this.modifierPressed(e)) { return; }
    e.preventDefault();
    this.$.diffCursor.createCommentInPlace();
  }

  _handleOpenLastFile(e) {
    // Check for meta key to avoid overriding native chrome shortcut.
    if (this.shouldSuppressKeyboardShortcut(e) ||
        this.getKeyboardEvent(e).metaKey) { return; }

    e.preventDefault();
    this._openSelectedFile(this._files.length - 1);
  }

  _handleOpenFirstFile(e) {
    // Check for meta key to avoid overriding native chrome shortcut.
    if (this.shouldSuppressKeyboardShortcut(e) ||
        this.getKeyboardEvent(e).metaKey) { return; }

    e.preventDefault();
    this._openSelectedFile(0);
  }

  _handleOpenFile(e) {
    if (this.shouldSuppressKeyboardShortcut(e) ||
        this.modifierPressed(e)) { return; }
    e.preventDefault();

    if (this._showInlineDiffs) {
      this._openCursorFile();
      return;
    }

    this._openSelectedFile();
  }

  _handleNextChunk(e) {
    if (this.shouldSuppressKeyboardShortcut(e) ||
        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
        this._noDiffsExpanded()) {
      return;
    }

    e.preventDefault();
    if (this.isModifierPressed(e, 'shiftKey')) {
      this.$.diffCursor.moveToNextCommentThread();
    } else {
      this.$.diffCursor.moveToNextChunk();
    }
  }

  _handlePrevChunk(e) {
    if (this.shouldSuppressKeyboardShortcut(e) ||
        (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
        this._noDiffsExpanded()) {
      return;
    }

    e.preventDefault();
    if (this.isModifierPressed(e, 'shiftKey')) {
      this.$.diffCursor.moveToPreviousCommentThread();
    } else {
      this.$.diffCursor.moveToPreviousChunk();
    }
  }

  _handleToggleFileReviewed(e) {
    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
      return;
    }

    e.preventDefault();
    if (!this._files[this.$.fileCursor.index]) { return; }
    this._reviewFile(this._files[this.$.fileCursor.index].__path);
  }

  _handleToggleLeftPane(e) {
    if (this.shouldSuppressKeyboardShortcut(e)) { return; }

    e.preventDefault();
    this._forEachDiff(diff => {
      diff.toggleLeftDiff();
    });
  }

  _toggleInlineDiffs() {
    if (this._showInlineDiffs) {
      this.collapseAllDiffs();
    } else {
      this.expandAllDiffs();
    }
  }

  _openCursorFile() {
    const diff = this.$.diffCursor.getTargetDiffElement();
    Gerrit.Nav.navigateToDiff(this.change, diff.path,
        diff.patchRange.patchNum, this.patchRange.basePatchNum);
  }

  /**
   * @param {number=} opt_index
   */
  _openSelectedFile(opt_index) {
    if (opt_index != null) {
      this.$.fileCursor.setCursorAtIndex(opt_index);
    }
    if (!this._files[this.$.fileCursor.index]) { return; }
    Gerrit.Nav.navigateToDiff(this.change,
        this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
        this.patchRange.basePatchNum);
  }

  _addDraftAtTarget() {
    const diff = this.$.diffCursor.getTargetDiffElement();
    const target = this.$.diffCursor.getTargetLineElement();
    if (diff && target) {
      diff.addDraftAtLine(target);
    }
  }

  _shouldHideChangeTotals(_patchChange) {
    return _patchChange.inserted === 0 && _patchChange.deleted === 0;
  }

  _shouldHideBinaryChangeTotals(_patchChange) {
    return _patchChange.size_delta_inserted === 0 &&
        _patchChange.size_delta_deleted === 0;
  }

  _computeFileStatus(status) {
    return status || 'M';
  }

  _computeDiffURL(change, patchRange, path, editMode) {
    // Polymer 2: check for undefined
    if ([change, patchRange, path, editMode]
        .some(arg => arg === undefined)) {
      return;
    }
    if (editMode && path !== this.MERGE_LIST_PATH) {
      return Gerrit.Nav.getEditUrlForDiff(change, path, patchRange.patchNum,
          patchRange.basePatchNum);
    }
    return Gerrit.Nav.getUrlForDiff(change, path, patchRange.patchNum,
        patchRange.basePatchNum);
  }

  _formatBytes(bytes) {
    if (bytes == 0) return '+/-0 B';
    const bits = 1024;
    const decimals = 1;
    const sizes =
        ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
    const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
    const prepend = bytes > 0 ? '+' : '';
    return prepend + parseFloat((bytes / Math.pow(bits, exponent))
        .toFixed(decimals)) + ' ' + sizes[exponent];
  }

  _formatPercentage(size, delta) {
    const oldSize = size - delta;

    if (oldSize === 0) { return ''; }

    const percentage = Math.round(Math.abs(delta * 100 / oldSize));
    return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
  }

  _computeBinaryClass(delta) {
    if (delta === 0) { return; }
    return delta >= 0 ? 'added' : 'removed';
  }

  /**
   * @param {string} baseClass
   * @param {string} path
   */
  _computeClass(baseClass, path) {
    const classes = [];
    if (baseClass) {
      classes.push(baseClass);
    }
    if (path === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) {
      classes.push('invisible');
    }
    return classes.join(' ');
  }

  _computePathClass(path, expandedFilesRecord) {
    return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
  }

  _computeShowHideIcon(path, expandedFilesRecord) {
    return this._isFileExpanded(path, expandedFilesRecord) ?
      'gr-icons:expand-less' : 'gr-icons:expand-more';
  }

  _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
    // Polymer 2: check for undefined
    if ([
      filesByPath,
      changeComments,
      patchRange,
      reviewed,
      loading,
    ].some(arg => arg === undefined)) {
      return;
    }

    // Await all promises resolving from reload. @See Issue 9057
    if (loading || !changeComments) { return; }

    const commentedPaths = changeComments.getPaths(patchRange);
    const files = Object.assign({}, filesByPath);
    Object.keys(commentedPaths).forEach(commentedPath => {
      if (files.hasOwnProperty(commentedPath)) { return; }
      files[commentedPath] = {status: 'U'};
    });
    const reviewedSet = new Set(reviewed || []);
    for (const filePath in files) {
      if (!files.hasOwnProperty(filePath)) { continue; }
      files[filePath].isReviewed = reviewedSet.has(filePath);
    }

    this._files = this._normalizeChangeFilesResponse(files);
  }

  _computeFilesShown(numFilesShown, files) {
    // Polymer 2: check for undefined
    if ([numFilesShown, files].some(arg => arg === undefined)) {
      return undefined;
    }

    const previousNumFilesShown = this._shownFiles ?
      this._shownFiles.length : 0;

    const filesShown = files.slice(0, numFilesShown);
    this.fire('files-shown-changed', {length: filesShown.length});

    // Start the timer for the rendering work hwere because this is where the
    // _shownFiles property is being set, and _shownFiles is used in the
    // dom-repeat binding.
    this.$.reporting.time(RENDER_TIMING_LABEL);

    // How many more files are being shown (if it's an increase).
    this._reportinShownFilesIncrement =
        Math.max(0, filesShown.length - previousNumFilesShown);

    return filesShown;
  }

  _updateDiffCursor() {
    // Overwrite the cursor's list of diffs:
    this.$.diffCursor.splice(
        ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
  }

  _filesChanged() {
    if (this._files && this._files.length > 0) {
      flush();
      const files = Array.from(
          dom(this.root).querySelectorAll('.file-row'));
      this.$.fileCursor.stops = files;
      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
    }
  }

  _incrementNumFilesShown() {
    this.numFilesShown += this.fileListIncrement;
  }

  _computeFileListControlClass(numFilesShown, files) {
    return numFilesShown >= files.length ? 'invisible' : '';
  }

  _computeIncrementText(numFilesShown, files) {
    if (!files) { return ''; }
    const text =
        Math.min(this.fileListIncrement, files.length - numFilesShown);
    return 'Show ' + text + ' more';
  }

  _computeShowAllText(files) {
    if (!files) { return ''; }
    return 'Show all ' + files.length + ' files';
  }

  _computeWarnShowAll(files) {
    return files.length > WARN_SHOW_ALL_THRESHOLD;
  }

  _computeShowAllWarning(files) {
    if (!this._computeWarnShowAll(files)) { return ''; }
    return 'Warning: showing all ' + files.length +
        ' files may take several seconds.';
  }

  _showAllFiles() {
    this.numFilesShown = this._files.length;
  }

  _computePatchSetDescription(revisions, patchNum) {
    // Polymer 2: check for undefined
    if ([revisions, patchNum].some(arg => arg === undefined)) {
      return '';
    }

    const rev = this.getRevisionByPatchNum(revisions, patchNum);
    return (rev && rev.description) ?
      rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
  }

  /**
   * Get a descriptive label for use in the status indicator's tooltip and
   * ARIA label.
   *
   * @param {string} status
   * @return {string}
   */
  _computeFileStatusLabel(status) {
    const statusCode = this._computeFileStatus(status);
    return FileStatus.hasOwnProperty(statusCode) ?
      FileStatus[statusCode] : 'Status Unknown';
  }

  _isFileExpanded(path, expandedFilesRecord) {
    return expandedFilesRecord.base.includes(path);
  }

  _onLineSelected(e, detail) {
    this.$.diffCursor.moveToLineNumber(detail.number, detail.side,
        detail.path);
  }

  _computeExpandedFiles(expandedCount, totalCount) {
    if (expandedCount === 0) {
      return GrFileListConstants.FilesExpandedState.NONE;
    } else if (expandedCount === totalCount) {
      return GrFileListConstants.FilesExpandedState.ALL;
    }
    return GrFileListConstants.FilesExpandedState.SOME;
  }

  /**
   * Handle splices to the list of expanded file paths. If there are any new
   * entries in the expanded list, then render each diff corresponding in
   * order by waiting for the previous diff to finish before starting the next
   * one.
   *
   * @param {!Array} record The splice record in the expanded paths list.
   */
  _expandedPathsChanged(record) {
    // Clear content for any diffs that are not open so if they get re-opened
    // the stale content does not flash before it is cleared and reloaded.
    const collapsedDiffs = this.diffs.filter(diff =>
      this._expandedFilePaths.indexOf(diff.path) === -1);
    this._clearCollapsedDiffs(collapsedDiffs);

    if (!record) { return; } // Happens after "Collapse all" clicked.

    this.filesExpanded = this._computeExpandedFiles(
        this._expandedFilePaths.length, this._files.length);

    // Find the paths introduced by the new index splices:
    const newPaths = record.indexSplices
        .map(splice => splice.object.slice(
            splice.index, splice.index + splice.addedCount))
        .reduce((acc, paths) => acc.concat(paths), []);

    // Required so that the newly created diff view is included in this.diffs.
    flush();

    this.$.reporting.time(EXPAND_ALL_TIMING_LABEL);

    if (newPaths.length) {
      this._renderInOrder(newPaths, this.diffs, newPaths.length);
    }

    this._updateDiffCursor();
    this.$.diffCursor.handleDiffUpdate();
  }

  _clearCollapsedDiffs(collapsedDiffs) {
    for (const diff of collapsedDiffs) {
      diff.cancel();
      diff.clearDiffContent();
    }
  }

  /**
   * Given an array of paths and a NodeList of diff elements, render the diff
   * for each path in order, awaiting the previous render to complete before
   * continung.
   *
   * @param  {!Array<string>} paths
   * @param  {!NodeList<!Object>} diffElements (GrDiffHostElement)
   * @param  {number} initialCount The total number of paths in the pass. This
   *   is used to generate log messages.
   * @return {!Promise}
   */
  _renderInOrder(paths, diffElements, initialCount) {
    let iter = 0;

    return (new Promise(resolve => {
      this.fire('reload-drafts', {resolve});
    })).then(() => this.asyncForeach(paths, (path, cancel) => {
      this._cancelForEachDiff = cancel;

      iter++;
      console.log('Expanding diff', iter, 'of', initialCount, ':',
          path);
      const diffElem = this._findDiffByPath(path, diffElements);
      if (!diffElem) {
        console.warn(`Did not find <gr-diff-host> element for ${path}`);
        return Promise.resolve();
      }
      diffElem.comments = this.changeComments.getCommentsBySideForPath(
          path, this.patchRange, this.projectConfig);
      const promises = [diffElem.reload()];
      if (this._loggedIn && !this.diffPrefs.manual_review) {
        promises.push(this._reviewFile(path, true));
      }
      return Promise.all(promises);
    }).then(() => {
      this._cancelForEachDiff = null;
      this._nextRenderParams = null;
      console.log('Finished expanding', initialCount, 'diff(s)');
      this.$.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
          EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
      this.$.diffCursor.handleDiffUpdate();
    }));
  }

  /** Cancel the rendering work of every diff in the list */
  _cancelDiffs() {
    if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
    this._forEachDiff(d => d.cancel());
  }

  /**
   * In the given NodeList of diff elements, find the diff for the given path.
   *
   * @param  {string} path
   * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
   * @return {!Object|undefined} (GrDiffElement)
   */
  _findDiffByPath(path, diffElements) {
    for (let i = 0; i < diffElements.length; i++) {
      if (diffElements[i].path === path) {
        return diffElements[i];
      }
    }
  }

  /**
   * Reset the comments of a modified thread
   *
   * @param  {string} rootId
   * @param  {string} path
   */
  reloadCommentsForThreadWithRootId(rootId, path) {
    // Don't bother continuing if we already know that the path that contains
    // the updated comment thread is not expanded.
    if (!this._expandedFilePaths.includes(path)) { return; }
    const diff = this.diffs.find(d => d.path === path);

    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
    if (!threadEl) { return; }

    const newComments = this.changeComments.getCommentsForThread(rootId);

    // If newComments is null, it means that a single draft was
    // removed from a thread in the thread view, and the thread should
    // no longer exist. Remove the existing thread element in the diff
    // view.
    if (!newComments) {
      threadEl.fireRemoveSelf();
      return;
    }

    // Comments are not returned with the commentSide attribute from
    // the api, but it's necessary to be stored on the diff's
    // comments due to use in the _handleCommentUpdate function.
    // The comment thread already has a side associated with it, so
    // set the comment's side to match.
    threadEl.comments = newComments.map(c => Object.assign(
        c, {__commentSide: threadEl.commentSide}
    ));
    flush();
    return;
  }

  _handleEscKey(e) {
    if (this.shouldSuppressKeyboardShortcut(e) ||
        this.modifierPressed(e)) { return; }
    e.preventDefault();
    this._displayLine = false;
  }

  /**
   * Update the loading class for the file list rows. The update is inside a
   * debouncer so that the file list doesn't flash gray when the API requests
   * are reasonably fast.
   *
   * @param {boolean} loading
   */
  _loadingChanged(loading) {
    this.debounce('loading-change', () => {
      // Only show set the loading if there have been files loaded to show. In
      // this way, the gray loading style is not shown on initial loads.
      this.classList.toggle('loading', loading && !!this._files.length);
    }, LOADING_DEBOUNCE_INTERVAL);
  }

  _editModeChanged(editMode) {
    this.classList.toggle('editMode', editMode);
  }

  _computeReviewedClass(isReviewed) {
    return isReviewed ? 'isReviewed' : '';
  }

  _computeReviewedText(isReviewed) {
    return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
  }

  /**
   * Given a file path, return whether that path should have visible size bars
   * and be included in the size bars calculation.
   *
   * @param {string} path
   * @return {boolean}
   */
  _showBarsForPath(path) {
    return path !== this.COMMIT_MESSAGE_PATH && path !== this.MERGE_LIST_PATH;
  }

  /**
   * Compute size bar layout values from the file list.
   *
   * @return {Gerrit.LayoutStats|undefined}
   *
   */
  _computeSizeBarLayout(shownFilesRecord) {
    if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
    const stats = {
      maxInserted: 0,
      maxDeleted: 0,
      maxAdditionWidth: 0,
      maxDeletionWidth: 0,
      deletionOffset: 0,
    };
    shownFilesRecord.base
        .filter(f => this._showBarsForPath(f.__path))
        .forEach(f => {
          if (f.lines_inserted) {
            stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
          }
          if (f.lines_deleted) {
            stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
          }
        });
    const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
    if (!isNaN(ratio)) {
      stats.maxAdditionWidth =
          (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
      stats.maxDeletionWidth =
          SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
      stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
    }
    return stats;
  }

  /**
   * Get the width of the addition bar for a file.
   *
   * @param {Object} file
   * @param {Gerrit.LayoutStats} stats
   * @return {number}
   */
  _computeBarAdditionWidth(file, stats) {
    if (stats.maxInserted === 0 ||
        !file.lines_inserted ||
        !this._showBarsForPath(file.__path)) {
      return 0;
    }
    const width =
        stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
  }

  /**
   * Get the x-offset of the addition bar for a file.
   *
   * @param {Object} file
   * @param {Gerrit.LayoutStats} stats
   * @return {number}
   */
  _computeBarAdditionX(file, stats) {
    return stats.maxAdditionWidth -
        this._computeBarAdditionWidth(file, stats);
  }

  /**
   * Get the width of the deletion bar for a file.
   *
   * @param {Object} file
   * @param {Gerrit.LayoutStats} stats
   * @return {number}
   */
  _computeBarDeletionWidth(file, stats) {
    if (stats.maxDeleted === 0 ||
        !file.lines_deleted ||
        !this._showBarsForPath(file.__path)) {
      return 0;
    }
    const width =
        stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
    return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
  }

  /**
   * Get the x-offset of the deletion bar for a file.
   *
   * @param {Gerrit.LayoutStats} stats
   *
   * @return {number}
   */
  _computeBarDeletionX(stats) {
    return stats.deletionOffset;
  }

  _computeShowSizeBars(userPrefs) {
    return !!userPrefs.size_bar_in_change_table;
  }

  _computeSizeBarsClass(showSizeBars, path) {
    let hideClass = '';
    if (!showSizeBars) {
      hideClass = 'hide';
    } else if (!this._showBarsForPath(path)) {
      hideClass = 'invisible';
    }
    return `sizeBars desktop ${hideClass}`;
  }

  /**
   * Shows registered dynamic columns iff the 'header', 'content' and
   * 'summary' endpoints are regiestered the exact same number of times.
   * Ideally, there should be a better way to enforce the expectation of the
   * dependencies between dynamic endpoints.
   */
  _computeShowDynamicColumns(
      headerEndpoints, contentEndpoints, summaryEndpoints) {
    return headerEndpoints && contentEndpoints && summaryEndpoints &&
           headerEndpoints.length === contentEndpoints.length &&
           headerEndpoints.length === summaryEndpoints.length;
  }

  /**
   * Returns true if none of the inline diffs have been expanded.
   *
   * @return {boolean}
   */
  _noDiffsExpanded() {
    return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
  }

  /**
   * Method to call via binding when each file list row is rendered. This
   * allows approximate detection of when the dom-repeat has completed
   * rendering.
   *
   * @param {number} index The index of the row being rendered.
   * @return {string} an empty string.
   */
  _reportRenderedRow(index) {
    if (index === this._shownFiles.length - 1) {
      this.async(() => {
        this.$.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
            RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
      }, 1);
    }
    return '';
  }

  _reviewedTitle(reviewed) {
    if (reviewed) {
      return 'Mark as not reviewed (shortcut: r)';
    }

    return 'Mark as reviewed (shortcut: r)';
  }

  _handleReloadingDiffPreference() {
    this._getDiffPreferences().then(prefs => {
      this.diffPrefs = prefs;
    });
  }
}

customElements.define(GrFileList.is, GrFileList);
